iT邦幫忙

2025 iThome 鐵人賽

DAY 2
0
Modern Web

用 Effect 實現產品級軟體系列 第 2

[學習 Effect Day2] 從 POC 到 Production(一)

  • 分享至 

  • xImage
  •  

生產環境的程式碼必須面對現實,而現實幾乎從來不是一路順遂的「快樂路徑(happy path)」。
Effect 作者:Michael Arnaldi

前一章節我們有提到 Product-grade 軟體實作的考量重點,但這不是一定要怎麼做的硬性規定。很多東西可能一開始開發的時候,你根本不需要,或甚至你還無法預料到你會面對到其中的哪些議題(不確定產品是否能發展下去,發展方向又是什麼)。所以通常我們是隨著產品成長的過程中發現這些議題。

接下來我想要透過程式碼的展示,來一步一步讓你理解如何將一個 POC 等級的程式碼轉化為 Product-grade 的軟體。順帶一提我會用Next.js 的開發框架去實作,但這不是重點,你可以用你熟悉的框架去實作。完整的程式碼在這兒。😀

"use client";

import { useEffect, useState } from "react";

type Todo = {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
};

const getTodo = async (id: number): Promise<unknown> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  return await res.json();
};

const getTodos = async (ids: number[]) => {
  const todos: unknown[] = [];
  for (const id of ids) {
    todos.push(await getTodo(id));
  }
  return todos;
};

export default function Home() {
  const [todos, setTodos] = useState<Todo[]>([]);

  useEffect(() => {
    async function main() {
      const list = (await getTodos([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])) as Todo[];
      setTodos(list);
      for (const todo of list) {
        console.log(`Got a todo: ${JSON.stringify(todo)}`);
      }
    }
    main();
  }, []);

  return (
    <main className="flex flex-col items-center justify-center h-screen">
      <h1>Todos</h1>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <span>{t.completed ? "[x]" : "[ ]"}</span>
            <span className="ml-3">{t.title}</span>
          </li>
        ))}
      </ul>
    </main>
  );
}

從上面程式碼可以看到,我們有一個 getTodos 函式,它會去抓取一個 Todo 的列表。但我們可以發現這個 function 沒有並行化 (Concurrency),導致有 fetch waterfall 的問題。解決方法是透過 chunkSize 限制每次抓取的數量,並使用 Promise.all 來並行化 fetch 資料。

const getTodos = async (ids: number[]) => {
  const chunkSize = 5;
  const todos: unknown[] = [];
  for (let i = 0; i < ids.length; i += chunkSize) {
    const chunk = ids.slice(i, i + chunkSize);
    const chunkTodos = await Promise.all(chunk.map(getTodo));
    todos.push(...chunkTodos);
  }
  return todos;
};

但這樣做就夠了嗎?現在的程式碼每5個資料抓取完畢後,才會再抓取下一個5個資料。一但有一個階段的其中一個 fetch 任務變慢,影響的會是整個資料 fetching 流程。但如果不用 chunk 一但資料很大,也會導致 fetch 資料的時間過長等問題。所以我們需要再更進一步的優化。

const getTodos = (ids: number[], limit = 5) => {
  const remaining = ids
    .slice(0, ids.length) // ids 的淺拷貝
    .map((id, index) => [id, index] as const) // 例如輸入 [101, 102, 103] → [[101,0],[102,1],[103,2]]
    .reverse(); // 為了方便後續用 pop() 拿出剩下的任務

  const results: unknown[] = []; // 用來存放 fetch 回來的資料,index 會對應到原來的 ids index
  return new Promise<unknown[]>((resolve, reject) => {
    // 起始並行請求
    let pending = 0; //  記錄目前正在執行的請求數量
    for (let i = 0; i < limit; i++) {
      fetchRemaining(); // 一開始會同時啟動 limit 個請求
    }

    function fetchRemaining() {
      if (remaining.length > 0) {
        const [remainingToFetchId, remainingToFetchIdx] = remaining.pop()!; // 拿出第一個任務,並從 remaining 中移除
        pending++; // 正在執行的請求數量 + 1
        getTodo(remainingToFetchId)
          .then((res) => {
            results[remainingToFetchIdx] = res; // 確保結果按照 ids 原始順序存放在 results。JavaScript 陣列允許「先放後面,再補前面」,即便 results[0] 還是 undefined,也不會影響後續再補上。
            pending--;
            fetchRemaining();
          })
          .catch((err) => reject(err)); // 如果有一個請求失敗,就 reject 整個 Promise
      } else if (pending === 0) {
        resolve(results); // 全部完成後 resolve(results)
      }
    }
  });
};

這個程式碼就是在實作一個「併發請求池」,用白話文簡單解釋一下運作原理

  1. 初始啟動:
for (let i = 0; i < limit; i++) {
  fetchRemaining();
}

假設 limit=5,就會啟動 5 個「工人」(worker),每個 worker 負責持續從 remaining pop 一個任務、執行、完成後再去拿下一個。

  1. 遞迴動作 (fetchRemaining):
getTodo(remainingToFetchId)
  .then((res) => {
    results[remainingToFetchIdx] = res;
    pending--;
    fetchRemaining(); // 這個才是接力
  })
  • 每個請求完成後,該 Worker 不會消失,而是接著處理下一個任務。
  • 因為 Worker 數在一開始就決定了,就是 limit 個。
  • 即使 fetchRemaining()then 裡呼叫了「自己」,它只會取代「剛完成的那個空檔 Worker」繼續幹活,不會再額外生成新的 Worker。

但這樣就夠了嗎?現在的程式碼在有請求失敗的時候,error 會讓我們 reject 整個 Promise。當然這沒問題,我們需要有一個完整的正確資料。但中斷之後,我們希望後續如果還有其他請求,應該被中斷以節省資源。以現在的程式碼來說,假設 limit=3,一開始派出三個 Worker:

  • Worker A (id=7)
  • Worker B (id=6)
  • Worker C (id=5)

Worker A 在執行的時候出錯,觸發了 reject(err)。這時 Promise 會進入失敗狀態,但 Worker B 和 Worker C 已經開始執行各自的請求流程了,所以還是會繼續跑完。只是結果不會被 resolve。為了不造成資源的浪費,我們應該在程式碼中加入中斷的機制。

中斷機制的後續我們下一篇繼續😀

參考資料:


上一篇
[學習 Effect Day1] 產品級軟體
系列文
用 Effect 實現產品級軟體2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言